<?php
/**
 * Class to simplify cron or recounting scripts creation.
 */
class CronScriptHelper implements ISingleton
{
    // Take care about NOT to clean this directory! So - it's a bad idea
    // to specify /tmp here, because it is cleared time after time.
    

    private static $instance;
    private $lockFile;
    private $stamps = null;
    private $selfScript = null;
    private $silent = false;

    protected function __construct($lockName, $silent) 
    {
		$lockPath = Config::getInstance()->cron->lock;    	
    	$lockDirs = array($lockPath, BASE_DIR . "/tmp/cronLock");
    	$this->silent = $silent;
        // Control that only one instance is running.
        if ($lockName) {
        	// Find writable lock dir.
        	$foundDir = null;
        	foreach ($lockDirs as $dir) {
        		if (!@is_dir($dir)) {
        			@mkdir($dir, 0777);
        		}
        		if (@is_writable($dir)) {
        			$foundDir = $dir;
        			break;
        		}
        	}
        	if (!$foundDir) {
        		$this->message("Directories %s are not writable, aborting!", join(", ", $lockDirs));
        		exit(1);
        	}
        	
        	// Perform the locking.
            $fname = $foundDir . "/CronScriptHelper_{$lockName}";
            $this->lockFile = fopen($fname, "a+");
            if (!$this->lockFile) {
        		$this->message("Cannot open/create %s!", $fname);
        		exit(2);
            }
            if (!flock($this->lockFile, LOCK_EX|LOCK_NB)) {
                $this->message("Script $lockName is already running.");
                exit();
            }
            
            // Write a warning message.
            fseek($this->lockFile, 0, SEEK_SET);
            ftruncate($this->lockFile, 0);
            fwrite($this->lockFile, "Do not remove this file!\n");
            flush($this->lockFile);
            // DO NOT CLOSE! The lock is held.
        }
        // Initialize environment AFTER locking, because in case of DB hang
        // it may took a lot of time, and we should not run multiple scripts
        // in this case too.
        self::initializeConsoleEnv();
        $this->stamps = $this->getCodeTimestamps();
        $this->selfScript = realpath($_SERVER['PHP_SELF']);
    }
    
    
    /**
     * @return CronScriptHelper
     */
    public static function getInstance($lockName = null, $silent = false)
    {
        if (self::$instance === null) {
            self::$instance = new CronScriptHelper($lockName, $silent);
        }
        return self::$instance;
    }    
    

    /**
     * Initializes the console environment.
     */
    function initializeConsoleEnv()
    {
        set_time_limit(0);
        error_reporting(E_ALL);
        while (ob_get_level()) ob_end_clean();
        if (!empty($_SERVER['GATEWAY_INTERFACE']) && !$this->silent) {
            echo "<xmp>";
        }
    }
    
    
    /**
     * Iterates over the list of IDs and calls a callback.
     * Also outputs various statistical information.
     */
    function iterate($objects, $func)
    {
        $startTS = $startQ = microtime(true);
        $i = $iQ = 0;
        $totalCount = count($objects);
        foreach ($objects as $obj) {
            // Show detailed quantum message?
            if ($iQ >= 50) {
                $dt = microtime(true) - $startTS;
                $dtQ = microtime(true) - $startQ;
                $left = round($dt / $i * ($totalCount - $i)); 
                $this->message(
                    "    (%02d:%02d:%02d left; %d objects/minute)",
                    $left / 3600, intval($left / 60) % 60, $left % 60,
                    floatval($iQ) / max($dtQ, 0.0001) * 60
                );
                /*
                if ($leakStat = Debug_CyclicRefsDetector::getStatistics()) {
                    $this->message("Objects in memory: " . print_r($leakStat, 1));
                }
				*/
                $startQ = microtime(true);
                $iQ = 0;
            }
            
            // Show this line progress.
            $nDigits = strlen($totalCount);
            $this->message(
                "Processing %{$nDigits}s of %d (%s)",
                $i+1, $totalCount,
                is_scalar($obj)? "object $obj" : (is_object($obj) && $obj->getId() ? "id=" . $obj->getId() : "#$i") 
            );

            // Execute an action with exception handling.
            for ($nTries = 5; $nTries >= 0; $nTries--) { 
                try {
                    call_user_func($func, $obj, $i, $totalCount);
                    break;
                } catch (Exception $e) {
                    $this->message("An exception happened:\n %s\n in file %s line %s", $e->getMessage(), $e->getFile(), $e->getLine());
                    if ($nTries > 0) {
                        $this->message("Waiting for 5 minutes and then retry.");
                        sleep(5 * 60);
                    } else {
                        $this->message("Retry count exceeded, exiting.");
                        exit();
                    }
                }
            }
            
            $i++;
            $iQ++;
        }
        
        // Print overall message.   
        $dt = microtime(true) - $startTS;
        $this->message(
            "All done (%02d:%02d:%02d; %d objects/minute)", 
            $dt / 3600, intval($dt / 60) % 60, $dt % 60,
            $i / max($dt, 0.0001) * 60
        );
    }
    
    
    /**
     * Outputs a sprintf-like message with timestamp.
     */
    function message()
    {
    	if ($this->silent) {
    		return;
    	}
        $args = func_get_args();
        if (count($args) > 1) {
            $msg = call_user_func_array('sprintf', $args);
        } else {
            $msg = current($args);
        }
        echo $msg . "\n"; 
        flush();
    }
    
    
    /**
     * Returns array of all files included by this script 
     * including their timestamps.
     * 
     * @return array
     */
    private function getCodeTimestamps()
    {
        clearstatcache();
        $files = get_included_files();
        $files[] = $this->selfScript;
        $stamps = array();
        foreach ($files as $f) {
            $stamp = @filemtime($f);
            if (!$stamp) {
                continue;
            }
            $stamps[$f] = $stamp;
        }
        return $stamps;
    }
    
    
    /**
     * Checks if the code is modified since the script is started OR the last check.
     * If true, terminates the script with a message.
     * 
     * @param bool $returnOnly  If true, do not terminate the script, only return true.
     * @return int              Nonzeto timestamp if the script is changed and $returnOnly is true.
     */
    function checkCodeIsChanged($returnOnly = false)
    {
        $stamps = $this->getCodeTimestamps();
        $changedFiles = array();
        foreach ($this->stamps as $f => $stamp) {
            if (!isset($stamps[$f])) {
                continue;
            }
            if ($stamps[$f] > $stamp) {
                $changedFiles[$f] = $stamp;
            }
        }
        if ($changedFiles) {
            if ($returnOnly) {
                return max(array_values($changedFiles));
            }            
            $this->message("Script files are changed, terminating. Will be resumed by cron.");
            foreach ($changedFiles as $f => $stamp) {
                $this->message("- file %s: changed at %s", $f, date("r", $stamp));
            }
            exit();
        }
        // During this preriod new files may be loaded, so save them.
        $this->stamps = $stamps;
        return null;
    }
}
